Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/santiagodc8/tu_perfil.net/llms.txt

Use this file to discover all available pages before exploring further.

TuPerfil.net uses Supabase Auth for all authentication. Sessions are stored in HTTP-only cookies and refreshed automatically by Next.js middleware on every request to a protected route.

Auth provider

Supabase Auth issues JWTs and manages sessions. The application does not implement its own password hashing, token generation, or session storage — all of that is handled by Supabase and surfaced through the @supabase/ssr package.

User roles

Two roles are supported, defined as a union type in src/types/index.ts:
src/types/index.ts
export type UserRole = 'admin' | 'editor';

export interface Profile {
  id: string;
  email: string;
  full_name: string;
  role: UserRole;
  created_at: string;
  updated_at: string;
}
Roles are stored in the profiles table, not in the Supabase JWT claims. Every user in auth.users gets a corresponding row in profiles created automatically by the handle_new_user trigger:
supabase/migrations/012_user_roles.sql
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
  INSERT INTO profiles (id, email, full_name, role)
  VALUES (
    NEW.id,
    NEW.email,
    COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
    -- The first user created is admin; all subsequent users are editors
    CASE
      WHEN (SELECT COUNT(*) FROM profiles) = 0 THEN 'admin'
      ELSE 'editor'
    END
  );
  RETURN NEW;
END;
$$;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW
  EXECUTE FUNCTION handle_new_user();
The first user created in a fresh Supabase project automatically receives the admin role. Every additional user starts as editor. You can promote an editor to admin by updating the role column in the profiles table from the Supabase dashboard.

Creating the first admin user

There is no public registration form. You create users directly in the Supabase dashboard:
  1. Open your Supabase project.
  2. Go to Authentication → Users.
  3. Click Add user and enter an email and password.
  4. The handle_new_user trigger fires immediately and creates a profiles row. If this is the first user, role is set to admin.

How middleware protects /admin routes

The file src/middleware.ts registers a single middleware function that runs on every request matching /admin/:path*:
src/middleware.ts
import { type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";

export async function middleware(request: NextRequest) {
  return await updateSession(request);
}

export const config = {
  matcher: ["/admin/:path*"],
};
The updateSession function in src/lib/supabase/middleware.ts does three things:
  1. Creates a short-lived Supabase server client that can read and write request/response cookies.
  2. Calls supabase.auth.getUser() to validate the session cookie. This makes a network call to Supabase on every matched request.
  3. Applies redirect rules based on the result:
src/lib/supabase/middleware.ts
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll();
        },
        setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  const { data: { user } } = await supabase.auth.getUser();

  // Protect /admin routes (except /admin/login)
  if (
    !user &&
    request.nextUrl.pathname.startsWith("/admin") &&
    !request.nextUrl.pathname.startsWith("/admin/login")
  ) {
    const url = request.nextUrl.clone();
    url.pathname = "/admin/login";
    return NextResponse.redirect(url);
  }

  // If already logged in and visiting /admin/login, redirect to dashboard
  if (user && request.nextUrl.pathname === "/admin/login") {
    const url = request.nextUrl.clone();
    url.pathname = "/admin";
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
}
Redirect logic summary
ConditionResult
No session + request to /admin/* (not /admin/login)Redirect to /admin/login
Active session + request to /admin/loginRedirect to /admin
Any other casePass request through unchanged

How the server-side Supabase client reads sessions

The server client in src/lib/supabase/server.ts uses @supabase/ssr to integrate with Next.js cookie storage:
src/lib/supabase/server.ts
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";

export function createClient() {
  const cookieStore = cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // Safe to ignore when called from a Server Component.
          }
        },
      },
    }
  );
}
The try/catch in setAll suppresses an error that Next.js throws when a Server Component tries to set a cookie. The middleware has already refreshed the session at the edge, so this is safe to ignore.

Auth helper functions

Two helper functions in src/lib/auth.ts make it easy to look up the current user’s role or full profile from any Server Component or API route:
src/lib/auth.ts
import { createClient } from "@/lib/supabase/server";
import type { UserRole } from "@/types";

/**
 * Returns the role of the currently authenticated user,
 * or null if there is no active session.
 */
export async function getCurrentUserRole(): Promise<UserRole | null> {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) return null;

  const { data: profile } = await supabase
    .from("profiles")
    .select("role")
    .eq("id", user.id)
    .single();

  return (profile?.role as UserRole) ?? null;
}

/**
 * Returns the full profile of the currently authenticated user,
 * or null if there is no active session.
 */
export async function getCurrentProfile() {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) return null;

  const { data: profile } = await supabase
    .from("profiles")
    .select("*")
    .eq("id", user.id)
    .single();

  return profile ?? null;
}
For operations that should be restricted to admins only (such as managing users or changing roles), server code calls getCurrentUserRole() and checks the result:
const role = await getCurrentUserRole();
if (role !== 'admin') {
  return new Response('Forbidden', { status: 403 });
}

Database-level is_admin() helper

The profiles RLS policies use a PostgreSQL helper function to enforce role-based access at the database level:
CREATE OR REPLACE FUNCTION is_admin()
RETURNS BOOLEAN
LANGUAGE sql
STABLE
SECURITY DEFINER
AS $$
  SELECT EXISTS (
    SELECT 1
    FROM profiles
    WHERE id = auth.uid()
      AND role = 'admin'
  );
$$;
This function is used in the profiles table RLS policies:
-- Only admins can update profiles
CREATE POLICY "profiles_admin_update"
  ON profiles FOR UPDATE
  TO authenticated
  USING (is_admin())
  WITH CHECK (is_admin());

-- Only admins can delete profiles
CREATE POLICY "profiles_admin_delete"
  ON profiles FOR DELETE
  TO authenticated
  USING (is_admin());

RLS access summary

This table summarizes what anonymous versus authenticated users can do across the most sensitive tables:
TableAnonymousAuthenticated (editor)Authenticated (admin)
articlesRead published, non-deletedRead all + writeRead all + write
categoriesReadRead + writeRead + write
contactsInsert onlyRead + update + deleteRead + update + delete
commentsInsert + read approvedRead all + moderateRead all + moderate
subscribersInsert (subscribe)Read + manageRead + manage
profilesNoneRead onlyRead + update + delete
adsRead activeRead + writeRead + write
page_viewsInsert onlyReadRead
ad_eventsInsert onlyReadRead
breaking_newsReadRead + writeRead + write
The admin client (createAdminClient() in src/lib/supabase/admin.ts) uses the SUPABASE_SERVICE_ROLE_KEY and bypasses all RLS policies. Use it only in server-side API routes and never expose it to the browser.